/****************************************************************************
 * Copyright (c) 2006, 2008 Remy Suen, Composent Inc., and others.
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * Contributors:
 *    Remy Suen <remy.suen@gmail.com> - initial API and implementation
 *
 * SPDX-License-Identifier: EPL-2.0
 *****************************************************************************/
package org.eclipse.ecf.protocol.bittorrent.internal.net;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedByInterruptException;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.Vector;

import org.eclipse.ecf.protocol.bittorrent.IHashCheckListener;
import org.eclipse.ecf.protocol.bittorrent.IPieceProgressListener;
import org.eclipse.ecf.protocol.bittorrent.ITorrentErrorListener;
import org.eclipse.ecf.protocol.bittorrent.ITorrentProgressListener;
import org.eclipse.ecf.protocol.bittorrent.ITorrentStateListener;
import org.eclipse.ecf.protocol.bittorrent.Torrent;
import org.eclipse.ecf.protocol.bittorrent.TorrentConfiguration;
import org.eclipse.ecf.protocol.bittorrent.TorrentFile;
import org.eclipse.ecf.protocol.bittorrent.TorrentServer;
import org.eclipse.ecf.protocol.bittorrent.internal.encode.BEncodedDictionary;
import org.eclipse.ecf.protocol.bittorrent.internal.encode.Decode;
import org.eclipse.ecf.protocol.bittorrent.internal.encode.Encode;
import org.eclipse.ecf.protocol.bittorrent.internal.torrent.DataFile;
import org.eclipse.ecf.protocol.bittorrent.internal.torrent.Piece;
import org.eclipse.ecf.protocol.bittorrent.internal.torrent.PieceState;

/**
 * The <code>TorrentManager</code> class is used to handle all of the
 * internals of what's being publicly exposed by {@link Torrent}.
 */
public class TorrentManager {

	private static final String DOWN_SPEED_KEY = "down.speed"; //$NON-NLS-1$

	private static final String UP_SPEED_KEY = "up.speed"; //$NON-NLS-1$

	private static MessageDigest shaDigest;

	private static File statePath;

	private final ConnectionPool connectionPool;

	/**
	 * An array of files that will be read and written to to exchange pieces
	 * with peers.
	 */
	private final DataFile[] files;

	private final Vector stateListeners;

	private final Vector progressListeners;

	private final Vector errorListeners;

	private final Vector pieceListeners;

	private final Vector hashCheckListeners;

	private final TorrentFile torrent;

	private final Properties properties;

	private final File propertiesFile;

	private final File targetFile;

	/**
	 * The file in which the amount of data that has been downloaded thus far
	 * for this torrent is stored. This allows the torrent to resume downloading
	 * or seeding very quickly.
	 */
	private final File torrentState;

	private final Vector pieces;

	/**
	 * A collection of {@link Piece}s that has had a portion of its bytes
	 * completed.
	 */
	private final Vector incompletePieces;

	/**
	 * An integer array that stores the number of peers that possesses a
	 * specific piece. This is used to determine which piece is a rare piece.
	 */
	private final int[] pieceAvailability;

	private final byte[] bitfield;

	/**
	 * A boolean array that indicates whether a specific piece has been
	 * downloaded successfully or not.
	 */
	private final boolean[] hasPiece;

	private final boolean[] priorityPieces;

	private final boolean[] interestedPieces;

	private final boolean[] uninterestedPieces;

	private final String infoHash;

	/**
	 * The URL of the tracker that this host should connect to.
	 */
	private final String tracker;

	/**
	 * A unique identification string is used to identify this client when
	 * talking with the tracker.
	 */
	private final String peerID = "E088----" + createPeerID(); //$NON-NLS-1$

	private final String hexHash;

	/**
	 * An additional identification that is required by some trackers in the
	 * event that the client's IP changes.
	 */
	private final char key = createKey();

	/**
	 * The total number of bytes that is encompassed by the torrent file. This
	 * only takes files into accounts and ignores the file size of folders.
	 */
	private final long total;

	/**
	 * The length of a piece.
	 */
	private final int pieceLength;

	private TrackerThread trackerThread;

	private SpeedMonitoringThread speedMonitoringThread;

	private HashCheckThread hashCheckThread;

	private PieceState[] states;

	/**
	 * A unique string that may be returned by the connected tracker for
	 * identification purposes.
	 */
	private String trackerID;

	/**
	 * The amount of bytes that has been downloaded from other peers thus far.
	 */
	private long downloaded = 0;

	/**
	 * The amount of bytes that has been uploaded to other peers thus far.
	 */
	private long uploaded = 0;

	/**
	 * The speed in which the data is being downloaded from peers in bytes as
	 * calculated from a twenty second rolling average.
	 */
	private long downSpeed = 0;

	/**
	 * The speed in which the data is being uploaded to peers in bytes as
	 * calculated from a twenty second rolling average.
	 */
	private long upSpeed = 0;

	private long maxDownSpeed = -1;

	private long maxUpSpeed = -1;

	private long requestDownSpeed = -1;

	private long requestUpSpeed = -1;

	/**
	 * The amount of data that has been discarded thus far because of hash check
	 * failures.
	 */
	private long discarded = 0;

	/**
	 * The amount of data that still needs to be downloaded for completion.
	 */
	private long remaining;

	/**
	 * The number of peers to request from the tracker.
	 */
	private int request = 50;

	/**
	 * The total number of seeds on the torrent as returned by the tracker. If
	 * the value is -1, the tracker has either not returned this value or the
	 * tracker has not yet been queried.
	 */
	private int seeders = -1;

	/**
	 * The total number of peers on the torrent as returned by the tracker. If
	 * the value is -1, the tracker has either not returned this value or the
	 * tracker has not yet been queried.
	 */
	private int peers = -1;

	/**
	 * The amount of time to wait before querying the tracker again for peers.
	 * This is in milliseconds.
	 */
	private int timeout = 1800000;

	/**
	 * The number of completed pieces thus far. This is updated within
	 * {@link #write(int, int, byte[], int, int)} and is used to inform attached
	 * {@link ITorrentProgressListener}s via the
	 * {@link #firePieceCompletedEvent(int)} method.
	 */
	private int completedPieces = 0;

	private int state = ITorrentStateListener.STOPPED;

	/**
	 * Used to indicate whether the torrent is currently running.
	 */
	private boolean running = false;

	/**
	 * Indicates that all the pieces have passed the SHA-1 hash check
	 * successfully.
	 */
	private boolean isCompleted = false;

	/**
	 * Indicates whether the user is deciding to not download some files.
	 */
	private boolean isSelective = false;

	private boolean isPrioritizing = false;

	private boolean isWaitingToStart = false;

	private boolean isHashChecking = false;

	static {
		try {
			shaDigest = MessageDigest.getInstance("SHA-1"); //$NON-NLS-1$
		} catch (NoSuchAlgorithmException e) {
			throw new RuntimeException(e);
		}
	}

	public static void setStatePath(File path) {
		statePath = path;
	}

	private static String createPeerID() {
		char[] numbers = new char[12];
		for (int i = 0; i < 12; i++) {
			numbers[i] = (char) (48 + ConnectionPool.RANDOM.nextInt(10));
		}
		return new String(numbers);
	}

	private static char createKey() {
		char key = (char) (ConnectionPool.RANDOM.nextInt(75) + 48);
		while (!Character.isDigit(key) && !Character.isLetter(key)) {
			key = (char) (ConnectionPool.RANDOM.nextInt(75) + 48);
		}
		return key;
	}

	/**
	 * Creates a new <code>Host</code> to begin with exchanging pieces with
	 * other pieces for the given torrent.
	 * 
	 * @param torrent
	 *            the torrent to use
	 * @param properties
	 *            the <code>Properties</code> instance to use to store
	 *            information, if <code>null</code>, a new instance will be
	 *            created
	 * @throws IOException
	 *             If an I/O error occurs whilst creating or hooking up with the
	 *             files associated with the torrent
	 */
	public TorrentManager(TorrentFile torrent, Properties properties)
			throws IOException {
		this.torrent = torrent;
		targetFile = torrent.getTargetFile();
		connectionPool = new ConnectionPool(this);
		tracker = torrent.getTracker();
		pieceLength = torrent.getPieceLength();
		infoHash = torrent.getInfoHash();
		hexHash = torrent.getHexHash();
		torrentState = new File(statePath, hexHash);
		total = torrent.getTotalLength();
		torrent.save(new File(statePath, hexHash + ".torrent")); //$NON-NLS-1$

		int numPieces = torrent.getNumPieces();
		pieces = new Vector(numPieces);
		trackerThread = new TrackerThread();
		states = PieceState.createStates(numPieces);
		for (int i = 0; i < numPieces; i++) {
			pieces.add(new Piece(states[i], i));
		}
		bitfield = new byte[numPieces % 8 != 0 ? (numPieces / 8) + 1
				: (numPieces / 8)];
		hasPiece = new boolean[numPieces];
		pieceAvailability = new int[numPieces];
		priorityPieces = new boolean[numPieces];
		interestedPieces = new boolean[numPieces];
		uninterestedPieces = new boolean[numPieces];
		incompletePieces = new Vector();
		stateListeners = new Vector();
		errorListeners = new Vector();
		pieceListeners = new Vector();
		progressListeners = new Vector();
		hashCheckListeners = new Vector();
		propertiesFile = new File(statePath, hexHash + ".properties"); //$NON-NLS-1$
		if (!propertiesFile.exists()) {
			properties = new Properties();
			properties.setProperty("target", targetFile.getAbsolutePath()); //$NON-NLS-1$
		} else if (properties == null) {
			properties = new Properties();
			properties.load(new FileInputStream(propertiesFile));
			restore(properties);
		} else {
			restore(properties);
		}
		this.properties = properties;
		store();

		String[] filenames = torrent.getFilenames();
		if (filenames.length != 1 && !targetFile.exists()
				&& !targetFile.mkdirs()) {
			throw new IOException("The folders needed by this torrent could not be created"); //$NON-NLS-1$
		}
		files = new DataFile[filenames.length];

		fileInitialization(filenames, targetFile);
		for (int i = 0; i < numPieces; i++) {
			Piece piece = (Piece) pieces.get(i);
			piece.setLength(pieceLength);
		}
		((Piece) pieces.get(numPieces - 1))
				.setLength((int) (total % pieceLength));

		checkFile();
		setPieces();
	}

	private void checkFile() {
		if (torrentState.exists()) {
			int count = 0;
			try {
				BufferedReader reader = new BufferedReader(new FileReader(
						torrentState));
				String input = reader.readLine();
				if (input != null
						&& Long.parseLong(input) == targetFile.lastModified()) {
					input = reader.readLine();
					PieceState[] states = PieceState.createStates(torrent
							.getNumPieces());
					while (input != null) {
						states[count++].parse(input);
						input = reader.readLine();
					}
					if (count != states.length) {
						startHashCheck();
					} else {
						setPieces(states);
					}
				} else {
					startHashCheck();
				}
			} catch (IOException e) {
				startHashCheck();
			}
		} else {
			startHashCheck();
		}
	}

	public boolean performHashCheck() {
		switch (state) {
		case ITorrentStateListener.STOPPED:
			startHashCheck();
			return true;
		case ITorrentStateListener.HASH_CHECKING:
			return true;
		default:
			return false;
		}
	}

	private void startHashCheck() {
		if (hashCheckThread == null || !hashCheckThread.isAlive()) {
			hashCheckThread = new HashCheckThread();
			hashCheckThread.start();
			isHashChecking = true;
			fireStateChangedEvent(ITorrentStateListener.HASH_CHECKING);
		}
	}

	private void fileInitialization(String[] filenames, File targetFile)
			throws IOException {
		if (files.length == 1) {
			long length = torrent.getLengths()[0];
			files[0] = new DataFile(targetFile, length);
			int[] pieces = new int[torrent.getPieces().length];
			for (int i = 0; i < pieces.length; i++) {
				pieces[i] = i;
			}
			files[0].setPieces(pieces, pieceLength, pieceLength);
			remaining = length;
		} else {
			int count = 0;
			int piece = 0;
			int currentLength = pieceLength;
			for (int i = 0; i < filenames.length; i++) {
				File file = new File(targetFile + File.separator + filenames[i]);
				if (!file.getParentFile().exists()
						&& !file.getParentFile().mkdirs()) {
					throw new IOException("The folders needed by this torrent could not be created"); //$NON-NLS-1$
				}

				long fileLength = torrent.getLengths()[i];
				files[i] = new DataFile(file, fileLength);

				if (currentLength > fileLength) {
					files[i].setPieces(new int[] { count }, (int) fileLength,
							(int) fileLength);
					currentLength -= fileLength;
					continue;
				} else if (currentLength == fileLength) {
					files[i].setPieces(new int[] { count }, (int) fileLength,
							(int) fileLength);
					currentLength = pieceLength;
					count++;
					piece++;
					continue;
				} else if (currentLength != pieceLength
						&& currentLength < fileLength) {
					fileLength -= currentLength;
					count++;
				}

				while (fileLength >= pieceLength) {
					count++;
					fileLength -= pieceLength;
				}
				count++;

				int[] pieces = new int[count - piece];
				for (int j = piece; j < count; j++) {
					pieces[j - piece] = j;
				}

				if (fileLength == 0) {
					files[i].setPieces(pieces, pieceLength, pieceLength);
				} else {
					files[i].setPieces(pieces, currentLength, pieceLength);
					currentLength = (int) (pieceLength - fileLength);
				}
				count--;
				piece = count;
			}
			remaining = total;
		}
	}

	private void setPieces() {
		int count = 0;
		int pieceLen = pieceLength;
		for (int i = 0; i < files.length; i++) {
			long length = files[i].length();
			if (pieceLen == 0) {
				pieceLen = pieceLength;
			}
			while (pieceLen < length) {
				Piece piece = (Piece) pieces.get(count);
				piece.addFile(files[i], pieceLen);
				count++;
				length -= pieceLen;
				if (pieceLen < pieceLength) {
					pieceLen = pieceLength;
				}
			}
			pieceLen -= length;
			((Piece) pieces.get(count)).addFile(files[i], (int) length);
		}
	}

	private void restore(Properties properties) {
		String value = properties.getProperty(DOWN_SPEED_KEY);
		requestDownSpeed = value != null ? Long.parseLong(value) : -1;
		value = properties.getProperty(UP_SPEED_KEY);
		requestUpSpeed = value != null ? Long.parseLong(value) : -1;
	}

	private void store() throws IOException {
		properties.setProperty(DOWN_SPEED_KEY, Long.toString(requestDownSpeed));
		properties.setProperty(UP_SPEED_KEY, Long.toString(requestUpSpeed));
		properties.store(new FileOutputStream(propertiesFile), null);
	}

	private void updateBitfield() {
		int count = 0;
		int size = hasPiece.length;
		// iterate over all the pieces by multiples of 8
		char[] bits = new char[8];
		for (int i = 0; i < size; i += 8) {
			Arrays.fill(bits, '0');
			// iterate over the 8 (or less) pieces within this segment
			for (int j = i; j < (i + 8) && j < size; j++) {
				// if we have this piece, flag it so
				if (hasPiece[j]) {
					bits[j - i] = '1';
				}
			}
			// encode the binary string into the bitfield
			bitfield[count] = Encode.encodeForBitfield(bits);
			count++;
		}
	}

	private void updateBitfield(int piece) {
		int offset = piece / 8;
		char[] bits = new char[8];
		Arrays.fill(bits, '0');
		for (int i = offset; i < (offset + 8) && i < hasPiece.length; i++) {
			if (hasPiece[i]) {
				bits[i - offset] = '1';
			}
		}
		bitfield[offset] = Encode.encodeForBitfield(bits);
	}

	/**
	 * Contacts the tracker to begin exchanging pieces with any peers that are
	 * found.
	 * 
	 * @throws IOException
	 *             If an error occurs while querying the tracker or connecting
	 *             to one of the provided peers
	 */
	public void start() throws IOException {
		if (hashCheckThread != null && hashCheckThread.isAlive()
				&& isHashChecking) {
			isWaitingToStart = true;
			return;
		} else if (!torrentState.exists()) {
			startHashCheck();
			isWaitingToStart = true;
			return;
		} else if (running) {
			return;
		}
		isWaitingToStart = false;
		running = true;
		speedMonitoringThread = new SpeedMonitoringThread();
		trackerThread = new TrackerThread();
		speedMonitoringThread.start();
		trackerThread.start();
		fireStateChangedEvent(ITorrentStateListener.STARTED);
		queryTracker("started"); //$NON-NLS-1$
		fireStateChangedEvent(ITorrentStateListener.EXCHANGING);
	}

	/**
	 * Stops downloading or seeding the torrent.
	 * 
	 * @throws IOException
	 *             If an IOException occurred while informing the tracker that
	 *             the client is stopping
	 */
	public void stop() throws IOException {
		isWaitingToStart = false;
		if (hashCheckThread != null && hashCheckThread.isAlive()) {
			hashCheckThread.interrupt();
			isHashChecking = false;
			hashCheckThread = null;
			fireStateChangedEvent(ITorrentStateListener.STOPPED);
			return;
		} else if (!running) {
			return;
		}
		state = ITorrentStateListener.STOPPED;
		trackerThread.interrupt();
		speedMonitoringThread.interrupt();
		trackerThread = null;
		speedMonitoringThread = null;
		running = false;
		connectionPool.close();
		queryTracker("stopped"); //$NON-NLS-1$
		fireStateChangedEvent(ITorrentStateListener.STOPPED);
		store();
	}

	/**
	 * Removes all previously saved status and configuration information
	 * regarding the opened torrent. This will call {@link #stop()} prior to the
	 * deletion of the files.
	 */
	public void remove() {
		try {
			stop();
		} catch (IOException e) {
			// ignored
		}

		File[] files = statePath.listFiles();
		for (int i = 0; i < files.length; i++) {
			if (files[i].getName().startsWith(hexHash)) {
				files[i].delete();
			}
		}

		remaining = total;
		seeders = -1;
		peers = -1;
		Arrays.fill(priorityPieces, false);
		Arrays.fill(interestedPieces, false);
		Arrays.fill(uninterestedPieces, false);
		for (int i = 0; i < pieces.size(); i++) {
			((Piece) pieces.get(i)).reset();
		}
	}

	public boolean delete() {
		remove();
		return targetFile.delete();
	}

	private void queryTracker(String event) throws IOException {
		String link = tracker
				+ "?info_hash=" //$NON-NLS-1$
				+ URLEncoder.encode(infoHash, "ISO-8859-1").replaceAll("\\+", //$NON-NLS-1$ //$NON-NLS-2$
						"%20") //$NON-NLS-1$
				+ "&peer_id=" //$NON-NLS-1$
				+ URLEncoder.encode(peerID, "ISO-8859-1").replaceAll("\\+", //$NON-NLS-1$ //$NON-NLS-2$
						"%20") + "&port=" + TorrentServer.getPort() //$NON-NLS-1$ //$NON-NLS-2$
				+ "&uploaded=" + uploaded + "&downloaded=" + downloaded //$NON-NLS-1$ //$NON-NLS-2$
				+ "&left=" + remaining //$NON-NLS-1$
				+ (event == null ? "" : "&event=" + event) + "&numwant=" //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
				+ request + "&compact=1" + "&key=" + key //$NON-NLS-1$ //$NON-NLS-2$
				+ (trackerID != null ? "&trackerid=" + trackerID : ""); //$NON-NLS-1$ //$NON-NLS-2$
		TorrentConfiguration.debug("Querying the tracker at " + link); //$NON-NLS-1$
		URL url = new URL(link);
		BEncodedDictionary dictionary = Decode.bDecode(url.openStream());
		if (event != null && event.equals("stopped")) { //$NON-NLS-1$
			return;
		}

		String failure = (String) dictionary.get("failure reason"); //$NON-NLS-1$
		if (failure != null) {
			fireTrackerErrorEvent(failure);
			TorrentConfiguration.debug("The client could not connect to the tracker, the reason provided was - " + failure); //$NON-NLS-1$
			return;
		}
		timeout = ((Long) dictionary.get("interval")).intValue() * 1000; //$NON-NLS-1$
		if (trackerID == null) {
			trackerID = (String) dictionary.get("tracker id"); //$NON-NLS-1$
		}

		Long number = (Long) dictionary.get("completed"); //$NON-NLS-1$
		seeders = number != null ? number.intValue() : -1;
		number = (Long) dictionary.get("incompleted"); //$NON-NLS-1$
		peers = number != null ? number.intValue() : -1;

		Object peersList = dictionary.get("peers"); //$NON-NLS-1$
		if (peersList instanceof List) {
			TorrentConfiguration.debug("No peers were returned"); //$NON-NLS-1$
			return;
		}

		String string = (String) peersList;
		byte[] bytes = string.getBytes("ISO-8859-1"); //$NON-NLS-1$
		for (int i = 0; i < string.length(); i += 6) {
			String ip = (bytes[i] & 0xff) + "." + (bytes[i + 1] & 0xff) + "." //$NON-NLS-1$ //$NON-NLS-2$
					+ (bytes[i + 2] & 0xff) + "." + (bytes[i + 3] & 0xff); //$NON-NLS-1$
			int port = Integer.parseInt(Integer
					.toHexString(bytes[i + 4] & 0xff)
					+ Integer.toHexString(bytes[i + 5] & 0xff), 16);
			connectionPool.connectTo(ip, port);
		}
	}

	public void connectTo(SocketChannel channel)
			throws UnsupportedEncodingException {
		connectionPool.connectTo(channel);
	}

	/**
	 * Retrieves the block of data that corresponds to the specified piece.
	 * 
	 * @param piece
	 *            the interested piece's number
	 * @return an array of bytes corresponding to the data that is encompassed
	 *         by the specified piece, or <code>null</code> if no data is
	 *         available
	 * @throws IllegalArgumentException
	 *             If <code>piece</code> is negative or if it is not one of
	 *             the piece numbers
	 * @throws IOException
	 *             If an I/O/ error occurs while reading the data from the local
	 *             files
	 */
	private byte[] getPiece(int piece) throws IOException {
		if (piece < 0) {
			throw new IllegalArgumentException("The piece number cannot be negative"); //$NON-NLS-1$
		} else if (piece >= hasPiece.length) {
			throw new IllegalArgumentException("The piece number " + piece //$NON-NLS-1$
					+ " does not exist"); //$NON-NLS-1$
		}

		byte[][] data = new byte[files.length][0];
		Arrays.fill(data, null);
		for (int i = 0; i < files.length; i++) {
			if (files[i].containsPiece(piece)) {
				data[i] = files[i].getData(piece);
			}
		}

		boolean empty = true;
		for (int i = 0; i < data.length; i++) {
			if (data[i] != null && data[i].length != 0) {
				empty = false;
				break;
			}
		}

		if (empty) {
			return null;
		}

		byte[] bytes = new byte[((Piece) pieces.get(piece)).getLength()];
		int offset = 0;
		for (int i = 0; i < data.length; i++) {
			if (data[i] != null) {
				System.arraycopy(data[i], 0, bytes, offset, data[i].length);
				offset += data[i].length;
			}
		}
		return bytes;
	}

	byte[] getPieceData(int number, int offset, int length)
			throws IllegalArgumentException, IOException {
		byte[] piece = getPiece(number);
		if (piece == null) {
			return null;
		}
		if (offset + length > piece.length) {
			throw new IllegalArgumentException("The block of data that is being requested goes beyond the range of the requested piece"); //$NON-NLS-1$
		}
		byte[] block = new byte[length];
		System.arraycopy(piece, offset, block, 0, length);
		return block;
	}

	/**
	 * Performs a hash check on the specified piece number to see whether the
	 * data is corrupt or not.
	 * 
	 * @param piece
	 *            the number of the piece to check
	 * @return <code>true</code> if the SHA-1 hash of the particular piece's
	 *         data matches the torrent file's hash, <code>false</code>
	 *         otherwise
	 * @throws IllegalArgumentException
	 *             If the specified piece has not been completed
	 * @throws IOException
	 *             If an I/O error occurs while retrieving the piece's data from
	 *             a particular file
	 */
	private boolean hashCheck(int piece) throws IllegalArgumentException,
			IOException {
		byte[] data = getPiece(piece);
		return data == null ? false : torrent.getPieces()[piece]
				.equals(new String(shaDigest.digest(data), "ISO-8859-1")); //$NON-NLS-1$
	}

	private void saveState() throws IOException {
		BufferedWriter writer = new BufferedWriter(new FileWriter(torrentState,
				false));
		writer.write(Long.toString(targetFile.lastModified()));
		writer.newLine();
		for (int i = 0; i < states.length; i++) {
			writer.write(states[i].toString());
			writer.newLine();
		}
		writer.flush();
	}

	/**
	 * Writes the data that has been received from a peer onto the local files.
	 * 
	 * @param number
	 *            the piece's number that this data corresponds to
	 * @param index
	 *            the position within the piece that the retrieved data starts
	 *            from
	 * @param data
	 *            the data sent from the peer
	 * @param offset
	 *            the offset within <code>data</code> that the actual bytes
	 *            for the files start at
	 * @param length
	 *            the amount of bytes of data that has been received
	 * @throws IOException
	 *             If an I/O error occurs while writing to a file, saving the
	 *             state information, performing a hash check, or querying the
	 *             tracker
	 */
	synchronized void write(int number, int index, byte[] data, int offset,
			int length) throws IOException {
		Piece piece = (Piece) pieces.get(number);
		if (!hasPiece[number] && piece.write(index, data, offset, length)) {
			remaining -= length;
			downloaded += length;
			saveState();
			fireBlockDownloadedEvent(number, index, length);
			if (!incompletePieces.contains(piece)) {
				incompletePieces.add(piece);
			}

			if (piece.isComplete()) {
				incompletePieces.remove(piece);
				checkCompletedPiece(piece, number);
			}
		}
	}

	private void checkCompletedPiece(Piece piece, int number)
			throws IOException {
		if (hashCheck(number)) {
			TorrentConfiguration
					.debug("Piece " + number + " passed hash check"); //$NON-NLS-1$ //$NON-NLS-2$
			hasPiece[number] = true;
			updateBitfield(number);
			firePieceCompletedEvent(++completedPieces);
			connectionPool.queueHaveMessage(number);
			for (int i = 0; i < hasPiece.length; i++) {
				if (!hasPiece[i]) {
					return;
				}
			}

			isCompleted = true;
			fireStateChangedEvent(ITorrentStateListener.FINISHED);
			connectionPool.disconnectSeeds();
			// let the tracker know that the download has completed
			queryTracker("completed"); //$NON-NLS-1$
		} else {
			TorrentConfiguration.debug("Piece " + number //$NON-NLS-1$
					+ " has failed the hash check"); //$NON-NLS-1$
			piece.reset();
			int pieceLength = piece.getLength();
			discarded += pieceLength;
			remaining += (remaining == total) ? 0 : pieceLength;
			firePieceDiscardEvent(number, pieceLength);
		}
	}

	synchronized Piece request(boolean[] peerPieces) {
		if (isCompleted) {
			return null;
		}

		Piece request = null;
		if (!isSelective) {
			request = request(hasPiece, peerPieces);
		} else if (isPrioritizing) {
			request = request(priorityPieces, peerPieces);
			if (request == null) {
				request = request(interestedPieces, peerPieces);
			}
		} else {
			request = request(interestedPieces, peerPieces);
		}
		return request;
	}

	private Piece request(boolean[] compare, boolean[] peerPieces) {
		boolean isInterested = false;
		if (compare == hasPiece) {
			for (int i = 0; i < peerPieces.length; i++) {
				if (!hasPiece[i] && peerPieces[i]) {
					isInterested = true;
					break;
				}
			}
		} else {
			for (int i = 0; i < peerPieces.length; i++) {
				if (compare[i] && peerPieces[i]) {
					isInterested = true;
					break;
				}
			}
		}

		if (!isInterested) {
			return null;
		}

		boolean hasIncompletePiece = false;
		for (int i = 0; i < incompletePieces.size(); i++) {
			Piece piece = (Piece) incompletePieces.get(i);
			if (peerPieces[piece.getNumber()]) {
				hasIncompletePiece = true;
				break;
			}
		}

		if (hasIncompletePiece) {
			if (incompletePieces.size() == 0) {
				return hasPiece == compare ? getRarePiece(peerPieces)
						: getRarePiece(compare, peerPieces);
			}
			Piece piece = (Piece) incompletePieces.get(ConnectionPool.RANDOM
					.nextInt(incompletePieces.size()));
			while (!peerPieces[piece.getNumber()]) {
				if (isCompleted) {
					return null;
				} else if (incompletePieces.size() == 0) {
					return hasPiece == compare ? getRarePiece(peerPieces)
							: getRarePiece(compare, peerPieces);
				}
				piece = (Piece) incompletePieces.get(ConnectionPool.RANDOM
						.nextInt(incompletePieces.size()));
			}
			return piece;
		} else {
			return hasPiece == compare ? getRarePiece(peerPieces)
					: getRarePiece(compare, peerPieces);
		}
	}

	private Piece getRarePiece(boolean[] peerPieces) {
		int min = pieceAvailability[0];
		for (int i = 1; i < peerPieces.length; i++) {
			if (!hasPiece[i] && peerPieces[i] && min > pieceAvailability[i]) {
				min = pieceAvailability[i];
			}
		}

		int size = pieces.size();
		int pieceNumber = ConnectionPool.RANDOM.nextInt(size);
		while (hasPiece[pieceNumber] || pieceAvailability[pieceNumber] != min
				|| !peerPieces[pieceNumber]) {
			if (isCompleted) {
				return null;
			}
			pieceNumber = ConnectionPool.RANDOM.nextInt(size);
		}
		return (Piece) pieces.get(pieceNumber);
	}

	private Piece getRarePiece(boolean[] compare, boolean[] peerPieces) {
		int min = pieceAvailability[0];
		for (int i = 1; i < peerPieces.length; i++) {
			if (!hasPiece[i] && compare[i] && peerPieces[i]
					&& min > pieceAvailability[i]) {
				min = pieceAvailability[i];
			}
		}

		int size = pieces.size();
		int pieceNumber = ConnectionPool.RANDOM.nextInt(size);
		while (hasPiece[pieceNumber] || pieceAvailability[pieceNumber] != min
				|| !compare[pieceNumber] || !peerPieces[pieceNumber]) {
			if (isCompleted) {
				return null;
			}
			pieceNumber = ConnectionPool.RANDOM.nextInt(size);
		}
		return (Piece) pieces.get(pieceNumber);
	}

	String getPeerID() {
		return peerID;
	}

	byte[] getBitfield() {
		return bitfield;
	}

	/**
	 * Used to indicate that the specified piece now has another user that has
	 * it. This information is for identifying which pieces are rare amongst the
	 * list of connected peers.
	 * 
	 * @param piece
	 *            the number of the piece
	 */
	void updatePieceAvailability(int piece) {
		pieceAvailability[piece]++;
	}

	void addPieceAvailability(boolean[] peerPieces) {
		if (peerPieces.length != pieceAvailability.length) {
			throw new IllegalArgumentException("The length of the array is not " + pieceAvailability.length); //$NON-NLS-1$
		}
		for (int i = 0; i < pieceAvailability.length; i++) {
			if (peerPieces[i]) {
				pieceAvailability[i]++;
			}
		}
	}

	void removePieceAvailability(boolean[] peerPieces) {
		if (peerPieces.length != pieceAvailability.length) {
			throw new IllegalArgumentException("The length of the array is not " + pieceAvailability.length); //$NON-NLS-1$
		}
		for (int i = 0; i < pieceAvailability.length; i++) {
			if (peerPieces[i]) {
				pieceAvailability[i]--;
			}
		}
	}

	void addToUploaded(long length) {
		uploaded += length;
	}

	/**
	 * Sets the maximum number of connections that the host should attempt to
	 * connect to. The default value is set to 50 although 30 peers should
	 * already be plenty. This value should not be heightened unless there is a
	 * good reason to do so as it will likely cause network congestions.
	 * 
	 * @param maxConnections
	 *            the maximum number of connections that should be used
	 */
	public void setMaxConnections(int maxConnections) {
		connectionPool.setMaxConnections(maxConnections);
	}

	/**
	 * Setup file download priority levels and whether a file should even be
	 * downloaded at all.
	 * 
	 * @param downloadChoices
	 *            an integer array which stores a value greater than zero if the
	 *            file should have a high priority, a value equal to zero if it
	 *            should have a regular priority, or less than zero if it should
	 *            not be downloaded at all, the values should correspond to the
	 *            files returned from {@link TorrentFile}'s
	 *            {@link TorrentFile#getFilenames()} method
	 */
	public synchronized void setFilesToDownload(int[] downloadChoices) {
		if (files.length != downloadChoices.length) {
			throw new IllegalArgumentException("The provided array should be of length " + files.length); //$NON-NLS-1$
		}

		for (int i = 0; i < downloadChoices.length; i++) {
			int[] pieces = files[i].getPieces();
			for (int j = 0; j < pieces.length; j++) {
				if (downloadChoices[i] > 0) {
					priorityPieces[j] = true;
				} else if (downloadChoices[i] == 0) {
					interestedPieces[j] = true;
				} else {
					uninterestedPieces[j] = uninterestedPieces[j] || false;
				}
			}
		}

		if (isSelective) {
			return;
		}

		for (int i = 0; i < downloadChoices.length; i++) {
			if (downloadChoices[i] > 0) {
				isPrioritizing = true;
				break;
			}
		}

		for (int i = 0; i < downloadChoices.length; i++) {
			for (int j = i + 1; j < downloadChoices.length; j++) {
				if (downloadChoices[i] != downloadChoices[j]) {
					isSelective = true;
					return;
				}
			}
		}
		isSelective = false;
	}

	public void setMaxDownloadSpeed(long maximum) {
		if (maximum < 1) {
			maxDownSpeed = -1;
			requestDownSpeed = -1;
		} else {
			maxDownSpeed = maximum;
			requestDownSpeed = maximum;
		}
	}

	public void setMaxUploadSpeed(long maximum) {
		if (maximum < 1) {
			maxUpSpeed = -1;
			requestDownSpeed = -1;
		} else {
			maxUpSpeed = maximum;
			requestUpSpeed = maximum;
		}
	}

	long getMaxDownloadSpeed() {
		return maxDownSpeed;
	}

	long getMaxUploadSpeed() {
		return maxUpSpeed;
	}

	long getDownloadRequestSpeed() {
		return requestDownSpeed;
	}

	long getUploadRequestSpeed() {
		return requestUpSpeed;
	}

	void updateDownloadRequestSpeed(int amount) {
		if (requestDownSpeed == -1) {
			return;
		}
		requestDownSpeed -= requestDownSpeed > amount ? amount
				: requestDownSpeed;
	}

	void updateUploadRequestSpeed(int amount) {
		if (requestUpSpeed == -1) {
			return;
		}
		requestUpSpeed -= requestUpSpeed > amount ? amount : requestUpSpeed;
	}

	/**
	 * Retrieves the amount that has been downloaded thus far since the original
	 * call to {@link #start()}.
	 * 
	 * @return the amount of bytes that has been downloaded from peers
	 */
	public long getDownloaded() {
		return downloaded;
	}

	/**
	 * Returns the number of bytes that has been uploaded to peers thus far
	 * since calling {@link #start()}.
	 * 
	 * @return the amount of bytes that has been uploaded to peers
	 */
	public long getUploaded() {
		return uploaded;
	}

	/**
	 * Retreives the number of bytes that are required to complete the download.
	 * 
	 * @return the number of bytes left to complete the download
	 */
	public long getRemaining() {
		return remaining;
	}

	/**
	 * Gets the downloading speed as calculated over a twenty second rolling
	 * average.
	 * 
	 * @return the speed at which bytes are being downloaded from peers
	 */
	public long getDownSpeed() {
		return downSpeed;
	}

	/**
	 * Retrieves the uploading speed per calculations over a twenty second
	 * rolling average.
	 * 
	 * @return the speed at which bytes are being uploaded to peers
	 */
	public long getUpSpeed() {
		return upSpeed;
	}

	public long getTimeRemaining() {
		return isCompleted ? 0 : downSpeed == 0 ? -1 : Math.round(remaining
				/ downSpeed);
	}

	/**
	 * Retrieves the amount of data that has been discarded thus far. This is
	 * caused by pieces that has failed the integrity hash check.
	 * 
	 * @return the amount of bytes that has been discarded
	 */
	public long getDiscarded() {
		return discarded;
	}

	/**
	 * Retrieves the number of peers that connections have been created for thus
	 * far.
	 * 
	 * @return the number of connected peers
	 */
	public int getConnectedPeers() {
		return connectionPool.getConnected();
	}

	/**
	 * Returns the number of seeds that are currently assisting with the
	 * distribution.
	 * 
	 * @return the number of connected seeds, if the value is <code>-1</code>,
	 *         the tracker does not support the distribution of this information
	 * @see #getPeers()
	 */
	public int getSeeds() {
		return seeders;
	}

	/**
	 * Returns the total number of peers that are downloading the torrent that
	 * this <code>Host</code> is associated with.
	 * 
	 * @return the total number of connected peers on the torrent, if the value
	 *         is <code>-1</code>, the tracker does not support the
	 *         distribution of this information
	 * @see #getSeeds()
	 */
	public int getPeers() {
		return peers;
	}

	/**
	 * Retrieves the torrent that was used to create this <code>Host</code>.
	 * 
	 * @return the <code>Torrent</code> associated with this
	 */
	public TorrentFile getTorrentFile() {
		return torrent;
	}

	/**
	 * Retrieves the current state in which the host is currently in. This could
	 * be any one of the states provided by the {@link ITorrentStateListener}
	 * interface.
	 * 
	 * @return the state that the host is currently in
	 * @see ITorrentStateListener#STARTED
	 * @see ITorrentStateListener#EXCHANGING
	 * @see ITorrentStateListener#STOPPED
	 * @see ITorrentStateListener#FINISHED
	 */
	public int getState() {
		return state;
	}

	/**
	 * Adds the specified listener to the collection of listeners within this
	 * host if it is not already contained. The listener will be notified of the
	 * changes of the current state of the torrent's activity. The event's state
	 * will correspond to the value returned from {@link #getState()}.
	 * 
	 * @param listener
	 *            the listener to notify
	 * @throws IllegalArgumentException
	 *             If <code>listener</code> is <code>null</code>
	 */
	public void addTorrentStateListener(ITorrentStateListener listener)
			throws IllegalArgumentException {
		synchronized (stateListeners) {
			if (!stateListeners.contains(listener)) {
				stateListeners.add(listener);
			}
		}
	}

	/**
	 * Adds the specified listener to the collection of listeners within this
	 * host if it is not already contained. The listener will be notified when
	 * another piece has been completed by verifying it against a hash sum.
	 * 
	 * @param listener
	 *            the listener to notify
	 * @throws IllegalArgumentException
	 *             If <code>listener</code> is <code>null</code>
	 */
	public void addTorrentProgressListener(ITorrentProgressListener listener)
			throws IllegalArgumentException {
		synchronized (progressListeners) {
			if (!progressListeners.contains(listener)) {
				progressListeners.add(listener);
			}
		}
	}

	/**
	 * Adds the specified listener to the collection of listeners within this
	 * host if it is not already contained. The listener will be notified when a
	 * piece has downloaded some amount of additional bytes.
	 * 
	 * @param listener
	 *            the listener to notify
	 * @throws IllegalArgumentException
	 *             If <code>listener</code> is <code>null</code>
	 */
	public void addPieceProgressListener(IPieceProgressListener listener)
			throws IllegalArgumentException {
		synchronized (pieceListeners) {
			if (!pieceListeners.contains(listener)) {
				pieceListeners.add(listener);
			}
		}
	}

	/**
	 * Adds the specified listener to the collection of listeners within this
	 * host if it is not already contained. The listener will be notified when
	 * errors such as tracker failures or hash check failures occurs.
	 * 
	 * @param listener
	 *            the listener to notify
	 * @throws IllegalArgumentException
	 *             If <code>listener</code> is <code>null</code>
	 */
	public void addTorrentErrorListener(ITorrentErrorListener listener)
			throws IllegalArgumentException {
		synchronized (errorListeners) {
			if (!errorListeners.contains(listener)) {
				errorListeners.add(listener);
			}
		}
	}

	public void addHashCheckListener(IHashCheckListener listener) {
		synchronized (hashCheckListeners) {
			if (!hashCheckListeners.contains(listener)) {
				hashCheckListeners.add(listener);
			}
		}
	}

	public boolean removeTorrentStateListener(ITorrentStateListener listener) {
		synchronized (stateListeners) {
			return stateListeners.remove(listener);
		}
	}

	public boolean removeTorrentProgressListener(
			ITorrentProgressListener listener) {
		synchronized (progressListeners) {
			return progressListeners.remove(listener);
		}
	}

	public boolean removePieceProgressListener(IPieceProgressListener listener) {
		synchronized (pieceListeners) {
			return pieceListeners.remove(listener);
		}
	}

	public boolean removeTorrentErrorListener(ITorrentErrorListener listener) {
		synchronized (errorListeners) {
			return errorListeners.remove(listener);
		}
	}

	public boolean removeHashCheckListener(IHashCheckListener listener) {
		synchronized (hashCheckListeners) {
			return hashCheckListeners.remove(listener);
		}
	}

	private synchronized void fireStateChangedEvent(int state) {
		this.state = state;
		for (int i = 0; i < stateListeners.size(); i++) {
			((ITorrentStateListener) stateListeners.get(i)).stateChanged(state);
		}
	}

	private void fireBlockDownloadedEvent(int piece, int index, int blockLength) {
		synchronized (pieceListeners) {
			for (int i = 0; i < pieceListeners.size(); i++) {
				((IPieceProgressListener) pieceListeners.get(i))
						.blockDownloaded(piece, index, blockLength);
			}
		}
	}

	private void firePieceCompletedEvent(int completed) {
		synchronized (progressListeners) {
			for (int i = 0; i < progressListeners.size(); i++) {
				((ITorrentProgressListener) progressListeners.get(i))
						.pieceCompleted(completed);
			}
		}
	}

	private void fireTrackerErrorEvent(String message) {
		synchronized (errorListeners) {
			for (int i = 0; i < errorListeners.size(); i++) {
				((ITorrentErrorListener) errorListeners.get(i))
						.trackerError(message);
			}
		}
	}

	private void firePieceDiscardEvent(int piece, int pieceLength) {
		synchronized (errorListeners) {
			for (int i = 0; i < errorListeners.size(); i++) {
				((ITorrentErrorListener) errorListeners.get(i)).pieceDiscarded(
						piece, pieceLength);
			}
		}
	}

	private void fireHashCheckedEvent(int piece) {
		synchronized (hashCheckListeners) {
			for (int i = 0; i < hashCheckListeners.size(); i++) {
				((IHashCheckListener) hashCheckListeners.get(i))
						.hashChecked(piece);
			}
		}
	}

	/**
	 * Loads the stored information regarding each piece's status information
	 * and updates the torrent as such.
	 * 
	 * @param states
	 *            a specification of how far and how much each piece has
	 *            downloaded
	 * @throws IllegalArgumentException
	 *             if the provided array's length is not equal to the number of
	 *             pieces
	 */
	private void setPieces(PieceState[] states) {
		if (this.states.length != states.length) {
			throw new IllegalArgumentException("The array's size should be " //$NON-NLS-1$
					+ this.states.length);
		}
		this.states = states;
		for (int i = 0; i < states.length; i++) {
			Piece piece = (Piece) pieces.get(i);
			if (piece.isComplete()) {
				completedPieces--;
				hasPiece[i] = false;
			}
			remaining += piece.getWritten();
			piece.setState(states[i]);
			int written = piece.getWritten();
			remaining -= written;
			if (written == piece.getLength()) {
				completedPieces++;
				hasPiece[i] = true;
				incompletePieces.remove(piece);
			} else if (written != 0 && !incompletePieces.contains(piece)) {
				incompletePieces.add(piece);
			}
		}
		updateBitfield();

		for (int i = 0; i < hasPiece.length; i++) {
			if (!hasPiece[i]) {
				return;
			}
		}
		isCompleted = true;
	}

	private class HashCheckThread extends Thread {

		private HashCheckThread() {
			super("Hash Check Thread - " + torrent.getName()); //$NON-NLS-1$
		}

		private void cleanup() {
			isHashChecking = false;
			fireStateChangedEvent(ITorrentStateListener.STOPPED);
		}

		public void run() {
			try {
				int read = 0;
				int count = 0;
				ByteBuffer buffer = ByteBuffer.allocate(pieceLength);
				for (int i = 0; i < files.length; i++) {
					FileChannel channel = files[i].getChannel();
					while ((read += channel.read(buffer)) == pieceLength) {
						if (isInterrupted()) {
							cleanup();
							return;
						}
						Piece piece = (Piece) pieces.get(count);
						if (piece.isComplete()) {
							completedPieces--;
							hasPiece[i] = false;
						}
						remaining += piece.getWritten();
						buffer.rewind();
						if (torrent.getPieces()[count]
								.equals(new String(shaDigest.digest(buffer
										.array()), "ISO-8859-1"))) { //$NON-NLS-1$
							piece.setAsCompleted();
							hasPiece[count] = true;
							completedPieces++;
							remaining -= piece.getLength();
						} else {
							piece.reset();
						}
						incompletePieces.remove(piece);
						fireHashCheckedEvent(count);
						count++;
						read = 0;
					}
				}

				if (read > 0) {
					if (isInterrupted()) {
						cleanup();
						return;
					}
					Piece piece = (Piece) pieces.get(count);
					if (piece.isComplete()) {
						completedPieces--;
						hasPiece[count] = false;
					}
					remaining += piece.getWritten();
					buffer.rewind();
					shaDigest.update(buffer.array(), 0, read);
					if (torrent.getPieces()[count].equals(new String(shaDigest
							.digest(), "ISO-8859-1"))) { //$NON-NLS-1$
						hasPiece[count] = true;
						piece.setAsCompleted();
						completedPieces++;
						remaining -= piece.getLength();
					} else {
						piece.reset();
					}
					incompletePieces.remove(piece);
					fireHashCheckedEvent(count);
				}

				updateBitfield();
				saveState();
				if (isInterrupted()) {
					cleanup();
					return;
				}
				isHashChecking = false;
				if (isWaitingToStart) {
					try {
						TorrentManager.this.start();
					} catch (IOException e) {
						throw new RuntimeException(e);
					}
				}
			} catch (ClosedByInterruptException e) {
				cleanup();
				try {
					saveState();
				} catch (IOException ex) {
					throw new RuntimeException(ex);
				}
			} catch (IOException e) {
				cleanup();
				throw new RuntimeException(e);
			}
		}
	}

	private class TrackerThread extends Thread {

		private TrackerThread() {
			super("Tracker Thread - " + torrent.getName()); //$NON-NLS-1$
		}

		public void run() {
			while (true) {
				try {
					Thread.sleep(timeout);
					queryTracker(null);
				} catch (IOException e) {
					throw new RuntimeException(e);
				} catch (InterruptedException e) {
					return;
				}
			}
		}
	}

	private class SpeedMonitoringThread extends Thread {

		private SpeedMonitoringThread() {
			super("Speed Monitoring Thread - " + torrent.getName()); //$NON-NLS-1$
		}

		public void run() {
			long totalDown;
			long totalUp;
			long lastDownloaded = downloaded;
			long lastUploaded = uploaded;
			long[] downloads = new long[20];
			long[] uploads = new long[20];

			while (true) {
				for (int i = 0; i < 20; i++) {
					try {
						Thread.sleep(1000);
					} catch (InterruptedException e) {
						return;
					}
					downloads[i] = downloaded - lastDownloaded;
					uploads[i] = uploaded - lastUploaded;

					totalDown = 0;
					totalUp = 0;
					for (int j = 0; j < 20; j++) {
						totalDown += downloads[j];
						totalUp += uploads[j];
					}

					downSpeed = totalDown / 20;
					upSpeed = totalUp / 20;
					lastDownloaded = downloaded;
					lastUploaded = uploaded;

					requestDownSpeed = maxDownSpeed;
					requestUpSpeed = maxUpSpeed;
				}
			}
		}
	}
}
